﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.SharePoint;
using System.Web.UI;
using System.Collections;
using Microsoft.SharePoint.Utilities;
using System.Text.RegularExpressions;

namespace CalendarWebpart
{
    public class VCalendar
    {
        public static void ExportVCalendar(SPList list, Page page)
        {
            // Set the correct content type for the document (now that we know it isn't an error)
            page.Response.ContentType = "text/calendar";
            page.Response.Headers["Content-Disposition"] = "attachment;filename=calendar.ics";

            // Write the VCalendar header
            WriteHeader(page, list);

            // Write the timezone section and return the tz alias
            String timeZone = WriteTimeZones(page);

            // Holds GUIDs for recurrence/exception mappings
            Hashtable idToGuid = new Hashtable();

            // Loop through each item and write it to the file
            foreach (SPListItem item in list.Items)
            {
                WriteEvent(page, item, timeZone, idToGuid);
            }

            // Wrap up the file
            Write(page, "END", "VCALENDAR");

            page.Response.End();
        }

        // Escape text as needed for compliance with RFC 2445 (Sec 4.3.11)
        private static String Escape(Object obj)
        {
            if (obj == null)
                return null;

            String text = Convert.ToString(obj);

            if (text.Equals(""))
                return null;

            text = text.Replace(@"\", "\\\\");
            text = text.Replace(";", "\\;");
            text = text.Replace(",", "\\,");
            text = text.Replace("\n", "\\n");
            return text;
        }

        private static void Write(Page page, String propName, Object value)
        {
            int i;

            if (value == null)
                return;

            String line = propName + ":" + Convert.ToString(value);

            // Fold the line after every 70 chars (RFC 2445 Sec 4.1)
            for (i = 69; i < line.Length; i = i + 69)
            {
                line.Insert(i, "\r\n ");
            }

            // Write the line out
            page.Response.Write(line + "\r\n");
        }

        private static void WriteHeader(Page page, SPList list)
        {
            // vCalendar header
            Write(page, "BEGIN", "VCALENDAR");
            Write(page, "VERSION", "2.0");

            // List name
            Write(page, "X-WR-CALNAME", Escape(list.Title));

            // Product id -- ?
            //Write("PRODID", "");

            // Calendar scale -- only Gregorian is supported? (RFC 2445 Section 4.7.1)
            Write(page, "CALSCALE", "GREGORIAN");

            // Method
            Write(page, "METHOD", "PUBLISH");
        }

        // Returns the day at which the timezone specified by tzRule began in the year (this + yearOffset)
        private static DateTime TimeZoneStart(SPSystemTime tzRule, int yearOffset)
        {
            // Start at day one of the month
            DateTime startDate = new DateTime(
                SPUtility.GetServerNow(SPContext.Current.Web).AddYears(yearOffset).Year,
                tzRule.Month,
                1,
                tzRule.Hour,
                tzRule.Minute,
                tzRule.Second);

            // Offset from the first ? to the first Mon/Tues/Wed, depending on the day we want to end up on
            int numToAdd = Convert.ToInt32(tzRule.DayOfWeek) - Convert.ToInt32(startDate.DayOfWeek);

            // Go to the first instance of the correct day of the week
            startDate = startDate.Add(new TimeSpan(numToAdd, 0, 0, 0));

            // Go to the correct occurrence of the day
            startDate = startDate.Add(new TimeSpan(7 * tzRule.Day, 0, 0, 0));

            return startDate;
        }

        // Turns RecurrenceData options into value for which day of the month (-1, 1, 2, 3, 4)
        private static int GetWeekdayOfMonthValue(string options)
        {
            int i = 0;

            if (options.Contains(@"weekdayofmonth=""first"""))
                i = 1;
            else if (options.Contains(@"weekdayofmonth=""second"""))
                i = 2;
            else if (options.Contains(@"weekdayofmonth=""third"""))
                i = 3;
            else if (options.Contains(@"weekdayofmonth=""fourth"""))
                i = 4;
            else if (options.Contains(@"weekdayofmonth=""last"""))
                i = -1;

            return i;
        }


        // Turns RecurrenceData field XML into iCalendar recurrence string
        private static String ParseRecurrence(String recurrence)
        {
            String recurValue;

            // Normalize the recurrence XML
            recurrence = recurrence.ToLower();

            // Build a regex to parse out the pattern
            Regex pattern = new Regex(@"<repeat><(?<FREQ>\w+)\s(?<OPTIONS>.*)/></repeat>");

            // Match against the XML
            Match match = pattern.Match(recurrence);

            // Pull out the recurrence type and associated options
            String freq = match.Groups["FREQ"].ToString().ToUpper();
            String options = match.Groups["OPTIONS"].ToString().ToLower();
            String byday = null;

            // Is there a weekly="true" flag in the options?
            if (freq.Equals("DAILY") && options.Contains(@"weekday=""true"""))
            {
                // There's no support for daily-weekday patterns in this format, so we have to do some mangling
                freq = "WEEKLY";
                byday = "MO,TU,WE,TH,FR";
            }

            // Put the frequency into the return value, but handle special cases for tricky month/year patterns
            if (freq.Equals("MONTHLYBYDAY"))
            {
                recurValue = "FREQ=MONTHLY";
            }
            else if (freq.Equals("YEARLYBYDAY"))
            {
                recurValue = "FREQ=YEARLY";
            }
            else
            {
                recurValue = "FREQ=" + freq;
            }

            // Build a regex to parse out the interval
            pattern = new Regex(@"(day|week|month|year)frequency=""(?<INTERVAL>\d+)""");

            // Match against the XML
            match = pattern.Match(options);

            // Pull out the interval
            String interval = match.Groups["INTERVAL"].ToString();

            // If we found an interval, add it to the recur pattern
            if (!interval.Equals(""))
            {
                recurValue = recurValue + ";INTERVAL=" + interval;
            }

            // Include the starting day of the week if we're a weekly pattern
            if (freq.Equals("WEEKLY"))
            {
                // Only set up the byday list if we didn't already mangle it to support weekday recurrences
                if (byday == null)
                {
                    // Initialize it so it's no longer null
                    byday = "";

                    // The list of day values we're going to scan for
                    String[] days = new String[] { "SU", "MO", "TU", "WE", "TH", "FR", "SA" };

                    foreach (String d in days)
                    {
                        // Is this day set in the recurrence options?
                        if (options.Contains(d.ToLower() + @"=""true"""))
                        {
                            // Add a comma if we're sticking our value on the end of an existing one
                            if (!byday.Equals(""))
                            {
                                byday = byday + ",";
                            }

                            // Add it to the list
                            byday = byday + d;
                        }
                    }
                }

                // Add the day pattern to the recur pattern
                recurValue = recurValue + ";BYDAY=" + byday;

                // Build a regex to parse out the first day of the week
                pattern = new Regex(@"<firstdayofweek>(?<FIRST>\w\w)</firstdayofweek>");

                // Match against the XML
                match = pattern.Match(recurrence);

                // Pull out the first day of the week
                String first = match.Groups["FIRST"].ToString().ToUpper();

                // Add it to the recur pattern
                recurValue = recurValue + ";WKST=" + first;
            }

            // Parse out monthly-specific options
            if (freq.Equals("MONTHLY"))
            {
                // Build a regex to parse out the day in a "every N months on the Nth" pattern
                pattern = new Regex(@"\sday=""(?<BYDAY>\d+)""");

                // Match against the pattern options XML
                match = pattern.Match(options);

                // Pull out the by day option
                String byDay = match.Groups["BYDAY"].ToString().ToUpper();

                // Add it to the recur pattern
                if (!byDay.Equals(""))
                {
                    recurValue = recurValue + ";BYMONTHDAY=" + byDay;
                }
            }

            // Parse out yearly-specific options
            if (freq.Equals("YEARLY"))
            {
                // Build a regex to parse out the month in a "every N (month) Nrd" pattern
                pattern = new Regex(@"\smonth=""(?<BYMONTH>\d+)""");

                // Match against the pattern options XML
                match = pattern.Match(options);

                // Pull out the by month option
                String byMonth = match.Groups["BYMONTH"].ToString().ToUpper();

                // Add it to the recur pattern
                if (!byMonth.Equals(""))
                {
                    recurValue = recurValue + ";BYMONTH=" + byMonth;
                }
            }

            // Parse out more complex monthly-and-yearly-specific options
            if (freq.Equals("MONTHLYBYDAY") || freq.Equals("YEARLYBYDAY"))
            {
                // Parse out the weekdayOfMonth property into an index we can use
                int i = GetWeekdayOfMonthValue(options);

                // Is this a yearly pattern?
                if (freq.Equals("YEARLYBYDAY"))
                {
                    // Yes, so parse out the month that it's supposed to occur in
                    pattern = new Regex(@"\smonth=""(?<MONTH>\d+)""");

                    // Match against the XML
                    match = pattern.Match(options);

                    // Pull out the number of occurrences
                    String monthIndex = match.Groups["MONTH"].ToString();

                    // Add it to the recurrence pattern
                    recurValue = recurValue + ";BYMONTH=" + monthIndex;
                }

                // Is this a "the Nth day" pattern?
                if (options.StartsWith(@"day=""true"""))
                {
                    recurValue = recurValue + ";BYMONTHDAY=" + i;
                }

                // Is this a "the Nth weekday" pattern?
                else if (options.Contains(@"weekday=""true"""))
                {
                    recurValue = recurValue + ";BYDAY=MO,TU,WE,TH,FR;BYSETPOS=" + i;
                }

                // Is this a "the Nth weekend day" pattern?
                // NOTE: this only works if the month doesn't start on a Sunday.  Since the
                // spec doesn't seem to support this pattern, I have to make it up.
                else if (options.Contains(@"weekend_day=""true"""))
                {
                    recurValue = recurValue + ";BYDAY=SU,SA;BYSETPOS=" + i;
                }

                // This must be a "the Nth D (day name)" pattern, then...
                else
                {
                    // The list of day values we're going to scan for
                    String[] days = new String[] { "SU", "MO", "TU", "WE", "TH", "FR", "SA" };

                    foreach (String d in days)
                    {
                        // Is this day set in the recurrence options?
                        if (options.Contains(d.ToLower() + @"=""true"""))
                        {
                            // It is -- and there can be only one of them -- so tack it on and exit the loop
                            recurValue = recurValue + ";BYDAY=" + i + d;
                            break;
                        }
                    }
                }
            }

            // Build a regex to parse out the number of occurrences
            pattern = new Regex(@"<repeatinstances>(?<REPEAT>\d+)</repeatinstances>");

            // Match against the XML
            match = pattern.Match(recurrence);

            // Pull out the number of occurrences
            String repeat = match.Groups["REPEAT"].ToString();

            // If we found a number of occurrences, add it to the recur pattern
            if (!repeat.Equals(""))
            {
                recurValue = recurValue + ";COUNT=" + repeat;
            }

            // Build a regex to parse out the end by date
            pattern = new Regex(@"<windowend>(?<ENDDATE>.+)</windowend>");

            // Match against the XML
            match = pattern.Match(recurrence);

            // Pull out the end date
            String endDate = match.Groups["ENDDATE"].ToString();

            // If we found a value, add it to the recur pattern
            if (!endDate.Equals(""))
            {
                DateTime endDT = Convert.ToDateTime(endDate);
                recurValue = recurValue + ";UNTIL=" + endDT.ToUniversalTime().ToString("yyyyMMddTHHmmssZ");
            }

            return recurValue;
        }

        private static SPListItem GetFirstInstance(SPListItem item)
        {
            DateTime itemStartDate = (DateTime)item["EventDate"];
            SPQuery query = new SPQuery();
            query.ExpandRecurrence = true;
            query.Query = "<Where><DateRangesOverlap><FieldRef Name=\"EventDate\" /><FieldRef Name=\"EndDate\" /><FieldRef Name=\"RecurrenceID\" /><Value Type=\"DateTime\"><Month /></Value></DateRangesOverlap></Where>";
            int monthOffset = 0;
            SPListItemCollection allItems;

            // Look up to a little over a month ahead
            while (monthOffset < 14)
            {
                // Start from (start date + offset*30) and look ahead a month
                query.CalendarDate = ((DateTime)item["EventDate"]).AddMonths(monthOffset);

                // Get all the items
                allItems = item.ParentList.GetItems(query);

                // These items are sorted earliest to latest by start date
                foreach (SPListItem expandedItem in allItems)
                {
                    // Did we find an instance that matches our series ID?
                    if (expandedItem.ID == item.ID)
                    {
                        return expandedItem;
                    }
                }

                // If we didn't find anything, look in the next month
                monthOffset++;
            }

            // Return null if we looked all over and couldn't find anything.
            return null;
        }

        private static ArrayList GetDeletedInstancesOfSeries(SPListItem item)
        {
            ArrayList deletedInstances = new ArrayList();

            // Get the ID of the master series
            int id = item.ID;

            // Loop through each item in the list
            foreach (SPListItem possibleItem in item.ParentList.Items)
            {
                // Find all items marked as a deleted recurrence item with a matching master series ID 
                if (Convert.ToBoolean(possibleItem["Recurrence"])
                    && (Convert.ToInt32(possibleItem["Event Type"]) == 3)
                    && (Convert.ToInt32(possibleItem["MasterSeriesItemID"]) == id))
                {
                    deletedInstances.Add(possibleItem);
                }
            }

            return deletedInstances;
        }

        private static String WriteTimeZones(Page page)
        {
            // Get the timezone in the web
            SPTimeZone tz = SPContext.Current.Web.RegionalSettings.TimeZone;

            // Get the timezone description
            String tzid = "tz"; // Escape(tz.Description);

            // If the bias from 'now' to standard time is 0, then we're in standard time
            bool isStandardTime = (tz.Information.StandardBias == 0);

            // Get the date that standard time began last and this year
            DateTime stdStartLastYear = TimeZoneStart(tz.Information.StandardDate, -1);
            DateTime stdStartThisYear = TimeZoneStart(tz.Information.StandardDate, 0);

            // Get the date that daylight time begins this year and next
            DateTime dayStartThisYear = TimeZoneStart(tz.Information.DaylightDate, 0);
            DateTime dayStartNextYear = TimeZoneStart(tz.Information.DaylightDate, 1);

            // Get the time that the server thinks it is
            DateTime now = SPUtility.GetServerNow(SPContext.Current.Web);

            // Get the date that standard time and daylight time begin
            DateTime standardBegin, daylightBegin;

            if (isStandardTime)
            {
                // If we're in standard time, we need to return when the current std time started.
                // This may have been just a few months ago (if we're in Nov/Dev), or last year
                // (if we're in Jan/Feb).  If the std time start date is ahead of us, then we want the one
                // for last year
                standardBegin = now.CompareTo(stdStartThisYear) <= 0 ? stdStartLastYear : stdStartThisYear;

                // If we're in standard time, we need to return when the next daylight time starts.
                // This may be in just a few months (if we're in Jan/Feb) or next year (if we're in Nov/Dec).
                // If we haven't gotten to the standard time start for this year yet, we want this year's daylight time.
                daylightBegin = now.CompareTo(stdStartThisYear) <= 0 ? dayStartThisYear : dayStartNextYear;
            }
            else
            {
                // We're in daylight time.  Std time start is always ahead of us.
                standardBegin = stdStartThisYear;

                // We're in daylight time.  It just started!
                daylightBegin = dayStartThisYear;
            }

            // Get the standard time bias (480 minutes -> 8 hours 'til UTC -> -8 hours behind)
            String standardBias = Convert.ToString((tz.Information.StandardBias == 0 ?
                tz.Information.Bias : tz.Information.Bias + tz.Information.StandardBias) / 60 * -1);

            // Pad correctly
            if (standardBias.Length == 1)
            {
                standardBias = standardBias.Insert(0, "+0");
            }
            else if ((standardBias.Length == 2) && (standardBias.Substring(0, 1).Equals("-")))
            {
                standardBias = standardBias.Insert(1, "0");
            }
            else if (standardBias.Length == 2)
            {
                standardBias = standardBias + "+";
            }

            standardBias = standardBias + "00";

            // Get the daylight time bias
            String daylightBias = Convert.ToString((tz.Information.DaylightBias == 0 ?
                tz.Information.Bias : tz.Information.Bias + tz.Information.DaylightBias) / 60 * -1);

            // Pad correctly
            if (daylightBias.Length == 1)
            {
                daylightBias = daylightBias.Insert(0, "+0");
            }
            else if ((daylightBias.Length == 2) && (daylightBias.Substring(0, 1).Equals("-")))
            {
                daylightBias = daylightBias.Insert(1, "0");
            }
            else if (daylightBias.Length == 2)
            {
                daylightBias = standardBias + "+";
            }

            daylightBias = daylightBias + "00";

            // Timezone header
            Write(page, "BEGIN", "VTIMEZONE");

            // Timezone ID (to identify this timezone later in the document)
            Write(page, "TZID", tzid);

            // Last modified
            Write(page, "LAST-MODIFIED", DateTime.Now.ToUniversalTime().ToString("yyyyMMddTHHmmssZ"));

            // Standard time header
            Write(page, "BEGIN", "STANDARD");

            // Date and time this timezone started (if we're currently in it) or will start (if we're not in it)
            Write(page, "DTSTART", standardBegin.ToString("yyyyMMddTHHmmss"));

            // Timeshift during this timezone
            Write(page, "TZOFFSETTO", standardBias);

            // Timeshift outside of this timezone -- ternary to match iCal output
            Write(page, "TZOFFSETFROM", isStandardTime ? "+0000" : daylightBias);

            // Standard time footer
            Write(page, "END", "STANDARD");

            // Daylight time header
            Write(page, "BEGIN", "DAYLIGHT");

            // Date and time this timezone started (if we're currently in it) or will start (if we're not in it)
            Write(page, "DTSTART", daylightBegin.ToString("yyyyMMddTHHmmss"));

            // Timeshift during this timezone
            Write(page, "TZOFFSETTO", daylightBias);

            // Timeshift outside of this timezone
            Write(page, "TZOFFSETFROM", !isStandardTime ? "+0000" : standardBias);

            // Daylight time footer
            Write(page, "END", "DAYLIGHT");

            // Timezone footer
            Write(page, "END", "VTIMEZONE");

            return tzid;
        }

        private static void WriteEvent(Page page, SPListItem item, String timeZone, Hashtable idToGuid)
        {
            bool allDay = Convert.ToBoolean(item["All Day Event"]);
            bool recur = Convert.ToBoolean(item["Recurrence"]) && (Convert.ToInt32(item["Event Type"]) == 1);
            bool exception = (Convert.ToInt32(item["Event Type"]) == 4);
            bool deleted = (Convert.ToInt32(item["Event Type"]) == 3);
            String guid = ((String)item["GUID"]).Trim(new char[] { '{', '}' });
            DateTime created = Convert.ToDateTime(item["Created"]);
            String url = SPContext.Current.Web.Url + item.ParentList.Forms[PAGETYPE.PAGE_DISPLAYFORM].ServerRelativeUrl + "?ID=" + item.ID;
            DateTime start = Convert.ToDateTime(item["EventDate"]);
            DateTime end = Convert.ToDateTime(item["EndDate"]);
            String recurValue = null;

            // Skip deleted instances, since they get handled when the master series is written out
            if (deleted)
                return;

            // Special handling for recurrences
            if (recur)
            {
                Write(page, "type", item["Event Type"]);

                // Get the item's recurrence XML
                String recurrence = ((String)item["RecurrenceData"]);

                // Parse it and generate a recurrence value
                recurValue = ParseRecurrence(recurrence);

                // We can't rely on the start date of the master, so get the first instance of the series
                SPListItem rItem = GetFirstInstance(item);

                // Did we find an instance?
                if (rItem == null)
                {
                    // We couldn't find an instance of this recurrence, so let's fudge and call it a normal event
                    recur = false;
                }
                else
                {
                    // Correct the start date and calculate the end date by adding the duration to the start date
                    start = Convert.ToDateTime(rItem["EventDate"]);
                    end = start.AddSeconds((int)rItem["Duration"]);
                }
            }

            // Event header
            Write(page, "BEGIN", "VEVENT");

            // Title
            Write(page, "SUMMARY", Escape(item.Title));

            // Location
            Write(page, "LOCATION", Escape(item["Location"]));

            // Description
            Write(page, "DESCRIPTION", Escape(item.Fields["Description"].GetFieldValueAsText(item["Description"])));

            // Created date
            Write(page, "DTSTAMP", created.ToString("yyyyMMddTHHmmssZ"));

            // Unique ID
            if (recur)
            {
                // Add the ID-to-GUID mapping for later, but only if it's a recurrence
                idToGuid.Add(item.ID, guid);
            }
            else if (exception)
            {
                // Replace this item's ID with the ID of the master series if it's an exception
                guid = (String)idToGuid[item["MasterSeriesItemID"]];
            }

            Write(page, "UID", guid);

            // Sequence (aka version)
            Write(page, "SEQUENCE", Convert.ToString(item["Version"]).Replace(".", ""));

            // URL
            Write(page, "URL;VALUE=URI", url);

            // Duration if we're an all-day event
            if (allDay)
            {
                // All-day events always end at :59, so we have to add one minute for this to work correctly
                Write(page, "DURATION", "P" + ((TimeSpan)(end.AddMinutes(1) - start)).Days + "D");
            }

            // Start date (different format if all-day event)
            if (allDay)
            {
                Write(page, "DTSTART;VALUE=DATE", start.ToString("yyyyMMdd"));
            }
            else
            {
                Write(page, "DTSTART;TZID=" + timeZone, start.ToString("yyyyMMddTHHmmss"));
            }

            // End date (only if not an all-day event)
            if (!allDay)
            {
                Write(page, "DTEND;TZID=" + timeZone, end.ToString("yyyyMMddTHHmmss"));
            }

            // Time of the instance we replaced, if we're an exception
            if (exception)
            {
                Write(page, "RECURRENCE-ID;TZID=" + timeZone, (Convert.ToDateTime(item["Recurrence ID"])).ToString("yyyyMMddTHHmmss"));
            }

            // Recurrence rule and deleted instances
            if (recur)
            {
                Write(page, "RRULE", recurValue);

                // Get all the deleted instances of this series
                ArrayList deletedInstances = GetDeletedInstancesOfSeries(item);

                // Loop through them
                foreach (SPListItem deletedInstance in deletedInstances)
                {
                    // Write out an exception rule for each one
                    Write(page, "EXDATE;TZID=" + timeZone, (Convert.ToDateTime(deletedInstance["Recurrence ID"])).ToString("yyyyMMddTHHmmss"));
                }
            }

            // Event footer
            Write(page, "END", "VEVENT");
        }
    }
}
